Métodos computacionales para las ciencias sociales

Procesamiento de texto IV

Temas

Tópicos sobre análisis de texto

  • Diversidad léxica
  • Keyness
  • Wordfish

Diversidad léxica

Propuesta de investigación

Nos interesa conocer la producción de textos de alumnos de tercero básico

Un indicador posible (entre otros) es la riqueza léxica

Supuesto: la riqueza léxica está asociada las habilidades de expresión escrita

Podemos estudiar cuántas palabras diferentes se usan en un texto

Ejemplo de juguete

library(quanteda.textstats)
library(quanteda)
texto1 <-  c("juego juego juego juego")
texto2 <- c("canto canto canto canto")
texto3 <- c("juego canto juego canto")

ejemplo <- data.frame(text = c(texto1, texto2, texto3))

ejemplo %>% 
  corpus() %>% 
  tokens() %>% 
  dfm() %>% 
  textstat_lexdiv()
  document  TTR
1    text1 0.25
2    text2 0.25
3    text3 0.50

Aplicación real

[1] "Esta Será Seguramente la última oportunidad en que me pueda dirigir a ustedes."
library("readtext")
allende <- readtext("data/discurso_allende.txt")  

dfm_mat <- allende %>% 
  corpus() %>% 
  tokens() %>% 
  dfm() 
  
diversidad <- textstat_lexdiv(dfm_mat)
print(diversidad)
              document       TTR
1 discurso_allende.txt 0.5032895
dfm_mat <- allende %>% 
  corpus() %>% 
  tokens() %>% 
  tokens_select(stopwords("es"), selection = "remove" ) %>% 
  dfm() 
  
diversidad <- textstat_lexdiv(dfm_mat)
print(diversidad)
              document       TTR
1 discurso_allende.txt 0.8148148

¿Y si lematizamos?

Comparación con otros políticos de la época

Comparación en el tiempo

Comparación entre tendencias políticas

¿Más ideas?

Análisis de frecuencia relativa (keyness)

Estamos interesados en comparar textos

Exploraremos noticias en inglés del Guardian (política, sociedad e internacional. 2012-2016)

library(quanteda)
library(quanteda.textstats)
library(quanteda.textplots)
library(lubridate)
require(quanteda.corpora) ## solo disponible en github

Descargar y procesar

corpus_guardian <- download("data_corpus_guardian")
tokens <- tokens(corpus_guardian, remove_punct = TRUE) 
dfm <- dfm(tokens)
print(dim(dfm)) 
[1]  6000 99310

6000 noticia procesadas

tstat_key <- textstat_keyness(dfm, 
                              target = year(dfm$date) >= 2016)
textplot_keyness(tstat_key)

Wordfish (Slapin & Proksch, 2008)

Modelo no supervisado para hacer posicionamiento ideológico

Nos permite posicionar textos en un espacio de una dimensión

Se basa únicamente en la frecuencia de las palabras

\(y_{ijt}=Poisson(\lambda_{ijt})\)

\(\lambda_{ijt} = exp(\alpha_{it} + ψ_{j} + β_j ∗ ω_{it})\)

\(j\): palabra

\(i\): partido

\(\alpha_{i}\): efecto fijo partido (textos muy largos)

\(ψ_j\): efecto fijo palabra (palabras usadas mucho por todos los partidos)

\(ω\): Parámetro que indica la posición de un actor/partido

\(β\): Poder discriminador de las palabras

Implementación wordfish

Declaraciones de principios de partidos

Obligación de publicarse por Ley de Transparencia

Tenemos algunos en pdf y otros en txt

library(readtext)
library(tidyverse)

# Cargar datos
data <- readtext("data/partidos/*.pdf") # algunos programas están en pdf
data2 <- readtext("data/partidos/*.txt") # otros están en txt 

data <- data %>% 
  bind_rows(data2)
# Sacar caracteres molestos
data_edit <- data %>% 
  mutate(text = str_replace_all(text, pattern = "\\n", " "),
         text = tolower(text)
         ) %>% 
  mutate(doc_id = case_when(
    doc_id == "Declaración_Principios_PC.pdf" ~ "pc",
    doc_id == "Declracion-de-Principios-PSCH_2018.pdf" ~ "ps",
    doc_id == "declaracion_rd.pdf" ~ "rd",
    doc_id == "cs.txt" ~ "cs",
    doc_id == "DECLARACION-PRINCIPIOS-PDC.pdf"  ~ "dc",
    doc_id == "DECLARACIÓN DE PRINCIPIOS RENOVACIÓN NACIONAL.pdf" ~ "rn",
    doc_id == "evopoli.txt" ~ "evopoli",
    doc_id == "Declaracion de principios 2017 udi.pdf" ~ "udi",
    doc_id == "republicanos.txt" ~ "republicanos",
    doc_id == "radical.txt" ~ "radical"
  )) %>% 
  mutate(izquierda = if_else(doc_id %in% c("rd", "pc", "ps", "cs"), 1, 0 )) %>% 
  filter(izquierda == 1 | doc_id == "dc" | doc_id == "rn")
tokens <- corpus(data_edit) %>% 
  tokens(remove_punct = TRUE, remove_numbers = T) %>%
  tokens_select(pattern = stopwords("es"), selection = "remove", min_nchar=3L) 

dfm <- tokens %>% 
  dfm()
library(quanteda.textmodels)
wf <- textmodel_wordfish( dfm, dir = c(2, 1) ) # 

# Función de quanteda para crear gráfico
textplot_scale1d(wf)  

El parámetro \(\theta\) corresponde a \(ω\) en el modelo inicial

# Construir intervalos de confianza 
theta <- data.frame(docs = wf$docs, theta = wf$theta, se = wf$se.theta) %>% 
  mutate(lower = theta -  1.96 * se,
         upper = theta +  1.96 * se
         )

theta %>% 
  mutate(docs = toupper(docs)) %>% 
  ggplot(aes(x =  reorder(docs, theta), y = theta)) +
  geom_point() +
  coord_flip() +
  geom_segment(aes(x = docs, y = lower , 
                   xend = docs, yend = upper ,
                   colour = "segment"))  +
  labs(title = "Puntajes wordfish a partir de declaración de principios de los partidos") +
  theme_bw() +
  theme(axis.text = element_text(size = 13),
        axis.title.y = element_blank(),
        legend.position = "none",
        plot.title = element_text(hjust = 0.5, size = 18),
        panel.border = element_blank()
        ) 

Poder descriminador de palabras

plot_words <-  textplot_scale1d(wf, margin="features", highlighted = c("comunista", "socialista", "recabarren", "revolucionarios", "empleo", "delictual", "religiosa", "chile"))
       
plot_words

Topic modeling

LDA: Latent Dirichlet allocation

Cada documento es tratado como una mezcla de tópicos

Cada tópico es una mezcla de palabras

library(topicmodels)

data("AssociatedPress")
AssociatedPress
<<DocumentTermMatrix (documents: 2246, terms: 10473)>>
Non-/sparse entries: 302031/23220327
Sparsity           : 99%
Maximal term length: 18
Weighting          : term frequency (tf)
lda <- LDA(AssociatedPress, k = 2, control = list(seed = 1234))
lda
A LDA_VEM topic model with 2 topics.

Topic modeling

beta: Probabilidad de que una palabra sea generada por un tópico

library(tidytext)
topics <- tidy(lda, matrix = "beta")
topics
# A tibble: 20,946 × 3
   topic term           beta
   <int> <chr>         <dbl>
 1     1 aaron      1.69e-12
 2     2 aaron      3.90e- 5
 3     1 abandon    2.65e- 5
 4     2 abandon    3.99e- 5
 5     1 abandoned  1.39e- 4
 6     2 abandoned  5.88e- 5
 7     1 abandoning 2.45e-33
 8     2 abandoning 2.34e- 5
 9     1 abbott     2.13e- 6
10     2 abbott     2.97e- 5
# ℹ 20,936 more rows

Topic modeling

top_terms <- topics %>%
  group_by(topic) %>%
  slice_max(beta, n = 10) %>% 
  ungroup() %>%
  arrange(topic, -beta)

top_terms %>%
  mutate(term = reorder_within(term, beta, topic)) %>%
  ggplot(aes(beta, term, fill = factor(topic))) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~ topic, scales = "free") +
  scale_y_reordered()

Topic modeling

beta_wide <- topics %>%
  mutate(topic = paste0("topic", topic)) %>%
  pivot_wider(names_from = topic, values_from = beta) %>% 
  filter(topic1 > .001 | topic2 > .001) %>%
  mutate(log_ratio = log2(topic2 / topic1))

beta_wide %>%
  mutate(new_log = abs(log_ratio)) %>% 
  slice_max(new_log, n = 10) %>% 
  ggplot(aes(x = fct_reorder(term, desc(new_log)) , y = log_ratio)) +
  geom_bar(stat = "identity") +
  coord_flip() +
  theme(axis.title.y = element_blank())

Bertopic

200.000 noticias de medios chilenos

Bertopic

Aquí encontrarás la documentación

Pasos de Bertopic

Utiliza todo lo que hemos aprendido hasta ahora

Pasos

  1. Todos los textos son transformados en vectores con un modelo de lenguaje
  2. Se aplica un algoritmo de reducción de dimensionalidad (pca, umap, tsne u otro)
  3. Se aplica un algoritmo de clustering (DBSCAN, kmeans u otro)
  4. Vectorización de los textos
  5. Esquema de ponderación mediante c-TF-IDF
  6. Opcional: fine-tuning de los tópicos

Paso 0: cargar datos

import pandas as pd

# Cargar datos
data = pd.read_parquet("data/titulos.parquet")

# Hacemos una muestra de 500 documentos
data = data.sample(n = 500, random_state=42)
docs = list(data.text)
del data
print(docs[0:10])
['Italia: TV pública abre expediente disciplinario a comentaristas deportivos por bromas sexistas y racistas durante transmisión', 'Revive la victoria de La Serena ante Coquimbo en el clásico', 'Vidal: Afirmación de Lavín sobre Bachelet fue "poco seria y vulgar"', 'Bush promulga mayor reforma de salud desde hace 40 años ', 'Primer caso de Ómicron en Chile: Es un extranjero proveniente de Ghana que vive en San Felipe', '¿Intento de estafa?: Qué hay detrás de las molestas y cada vez más comunes "llamadas silenciosas"', 'Shirin Ebadi apoya boicot a elecciones parlamentarias iraníes', 'Falabella potencia su presencia en Colombia', 'A tres semanas de partir vacunación en niños de 6 a 11 años, el 53% ha recibido la primera dosis', 'Curacaví y Vitacura las con cifras más preocupantes: La realidad de las comunas que no avanzarán de fase en la RM']

Paso 1: construir embeddings

from bertopic import BERTopic
from sentence_transformers import SentenceTransformer

# Modelo de embeddings
embedding_model = SentenceTransformer("all-MiniLM-L6-v2")

# Generamos la instancia de BERTopic
topic_model = BERTopic(embedding_model=embedding_model)

# Generamos los embeddings
topic_model.fit_transform(docs)
# Guardamos los embeddings en un un arreglo
embeddings =  topic_model._extract_embeddings(docs, method="document")
print(embeddings.shape)
(500, 384)

Paso 2: reducción de dimensionalidad

Aplicamos UMAP a la matriz de embeddings

from umap import UMAP
umap_model = UMAP(n_neighbors=15, n_components=5, min_dist=0.0, metric='cosine')
umap_embeddings = umap_model.fit_transform(embeddings)
print(umap_embeddings.shape)
(500, 5)

Paso 3: clustering

Aplicamos HDBSCAN a la matriz reducida que viene de UMAP

El tópico -1 corresponde a outliers

from hdbscan import HDBSCAN
hdbscan_model = HDBSCAN(min_cluster_size=3, metric='euclidean', cluster_selection_method='eom', prediction_data=True)
hdbscan_model.fit(umap_embeddings);
clusters = hdbscan_model.labels_
print(clusters)
[10 17  5 -1 37 -1 44 42 -1 34 -1 34 12 12 -1 -1 32  1 -1 34 40 -1 37 42
 33 18 40 -1 44 32 14 42 -1 16 25 19 -1 37 31 28 37 33 -1 35 16 40 23 31
 -1 15 -1  4  7 18 28 19 -1 12 10 37  9 37  2 34 40 41 28 34 11 -1 11 33
 32  3  4 33  7 21 15 -1 38 18  5 44  7 32 -1 -1 22 -1 35 45 -1 -1 -1 -1
 -1 11 20 23 38 45  3 18 40 36 40 21 39 17 37 26 45 -1 45 -1 19 16  6 -1
 -1 37 34 -1  1 28 24 -1 16 -1  9 28 -1 18 -1 37 24 27 36 -1 22 -1 14 34
 34 25 -1 34 37 -1 26 14 29 21 26 14 44 30 18 -1 42 37 14  6 -1 -1  7 31
 -1  6 -1 -1  8  2 28 -1 34  2 -1 34  0 31  1 -1 17 14  2 -1 31 18 -1 41
 26 17 -1 20 38 -1 34 -1 10  7 40 20  9 14  5 -1 -1 13 14 37  4 14 31  0
 -1 23 -1  5  4  0 35  9 11 -1 29  4 39 -1 13 -1 10 34 18 39 44 -1 -1 -1
 33 -1 26 29 37 -1 -1  5 34 -1 12  9  7 43 24 22 43 35 23 -1 42  5  4 -1
 19 -1 34 15 35 33 -1 -1  3 21 34  9 32 -1  4 -1 15 -1 12 15  5 37 37 -1
 -1 -1 32  1 24 42 38 21  9 -1 15 -1 -1 11 37 30 -1 40 -1 18 -1  4 -1 20
 -1 -1 24 38  8 -1 28  4 12 17 21 22 33  1  6 -1 16 30 17 25 41 13 -1 -1
 -1 37 -1 12 -1 29 -1  2 15 13 45 18 38 -1  7 28 21 18 -1 -1  8  4 40 -1
 -1 24 31 -1 37 16 34 -1 -1 37 38  7 -1 41 18 34 -1 32 45  9 41 -1  8 45
 -1 11 -1 14  4 20 22 -1 34 24 16 -1  0 42 -1 37 36 37 -1 16 -1 -1 31 -1
 36 11 32  8 -1 -1 45 30 35 -1 -1 25 34 38 -1 -1 13 21 37 -1 14 -1 30 22
 43 -1 38 31 21 35 21 -1 30 31 -1 40  6 -1 29 -1 27 10  7  2 37 15 37 -1
 34 -1  4  7 -1 23 13 35 -1 -1 44 -1  5 22 43 17  1 25 18 -1 15 13 37  8
 -1 40 -1 -1  8 -1  4 -1 25 37 14 35 27 -1 26 -1 37 -1 -1 18]

Paso 4: vectorización

Los textos son transformados en una matriz BoW

from sklearn.feature_extraction.text import CountVectorizer
import nltk
from nltk.corpus import stopwords
import numpy as np

# Descargar stopwords en español
#nltk.download('stopwords') solo se descarga una vez
stopwords_es = stopwords.words('spanish')

# Creaamos la instancia del vectorizador
vectorizer_model = CountVectorizer(stop_words=stopwords_es, min_df = 3)

# Transformamos los documentos en una matriz de términos
X = vectorizer_model.fit_transform(docs)   

Paso 4: vectorización (continuación)

print("Las dimensiones de la matriz son: ", X.shape)
Las dimensiones de la matriz son:  (500, 318)
print("Algunas palabras del vocabulario:")
Algunas palabras del vocabulario:
vocab = np.array(vectorizer_model.get_feature_names_out())

print(vocab[0:20])
['10' '11' '20' '200' '2021' '2023' '40' '60' '8220' '8221' 'abre' 'abril'
 'acuerdo' 'acusa' 'acusación' 'ahora' 'alcalde' 'alerta' 'alto' 'alza']

Paso 5: c-TF-IDF

Cada tópico es representado con un enfoque c-TF-IDF

El peso de la palabra \(w\) en el tópico \(c\) está dado por

\[ {W}_{w,c} = tf_{w,c} * log(1 + \frac{A}{f_{w}}) \]

  • \(tf_{t,c}\): frecuencia de la palabra \(x\) en la clase \(c\)
  • \(A\): Promedio de palabras por clase
  • \(f_{w}\): frecuencia de la palabra \(w\) en todas las clases

Paso 5: c-TF-IDF (continuación)

# Excluimos los outliers
topic_ids = sorted([c for c in set(clusters) if c != -1])
topic_ids
[np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(9), np.int64(10), np.int64(11), np.int64(12), np.int64(13), np.int64(14), np.int64(15), np.int64(16), np.int64(17), np.int64(18), np.int64(19), np.int64(20), np.int64(21), np.int64(22), np.int64(23), np.int64(24), np.int64(25), np.int64(26), np.int64(27), np.int64(28), np.int64(29), np.int64(30), np.int64(31), np.int64(32), np.int64(33), np.int64(34), np.int64(35), np.int64(36), np.int64(37), np.int64(38), np.int64(39), np.int64(40), np.int64(41), np.int64(42), np.int64(43), np.int64(44), np.int64(45)]

# Sumamos las frecuencias de cada palabra en cada tópico
X_topics = []
for c in topic_ids:
    idx = np.where(clusters == c)[0] # indices del cluster c
    X_topics.append(X[idx].sum(axis=0)) # sumar las frecuencias de cada palabra del cluster c

# COnvertir a una matriz de (n_topic, n_terms)
X_topics = [np.array(row).ravel() for row in X_topics]  # lista de (n_terms,)
X_topics = np.array(X_topics)

Paso 5: c-TF-IDF (continuación)

from bertopic.vectorizers import ClassTfidfTransformer
ctfidf = ClassTfidfTransformer().fit_transform(X_topics, len(docs))
print("Las dimensiones de la matriz son: ", ctfidf.shape)
Las dimensiones de la matriz son:  (46, 318)

Todo junto

from bertopic.representation import KeyBERTInspired

# Paso 1 - Extract embeddings
embedding_model = SentenceTransformer("all-MiniLM-L6-v2")

# Paso 2 - Reduce dimensionality
umap_model = UMAP(n_neighbors=15, n_components=5, min_dist=0.0, metric='cosine')

# Paso 3 - Cluster reduced embeddings
hdbscan_model = HDBSCAN(min_cluster_size=35, metric='euclidean', cluster_selection_method='eom', prediction_data=True)

# Paso 4 - Tokenize topics
vectorizer_model = CountVectorizer(stop_words=stopwords_es, min_df = 3)

# Paso 5 - Create topic representation
ctfidf_model = ClassTfidfTransformer()

# Step 6 - (Optional) Fine-tune topic representations with
# a `bertopic.representation` model
representation_model = KeyBERTInspired()

# All steps together
topic_model = BERTopic(
  embedding_model=embedding_model,          # Step 1 - Extract embeddings
  umap_model=umap_model,                    # Step 2 - Reduce dimensionality
  hdbscan_model=hdbscan_model,              # Step 3 - Cluster reduced embeddings
  vectorizer_model=vectorizer_model,        # Step 4 - Tokenize topics
  ctfidf_model=ctfidf_model,                # Step 5 - Extract topic words
  representation_model=representation_model # Step 6 - (Optional) Fine-tune topic representations
)

# Correr el modelo
topics, probs = topic_model.fit_transform(docs)

Cargaremos el modelo ya entrenado

import pandas as pd
import pickle
import numpy as np
import plotly.express as px
import datamapplot as dmp
import pickle
import io
import torch
# Cargar títulos de noticias 
data = pd.read_parquet("data/titulos.parquet")
docs = list(data.text)
del data

# Cargar algunas cosas que están dentro del modelo
with open('data/bertopic_results.pkl', 'rb') as f:
    data = pickle.load(f)
    topics = data['topics']
    probs = data['probs']


# Cargar el modelo completo
#with open('data/bertopic_model.pkl', 'rb') as f:
#    topic_model = pickle.load(f)

# Clase para cargar el modelo en CPU
class CPU_Unpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if module == 'torch.storage' and name == '_load_from_bytes':
            return lambda b: torch.load(io.BytesIO(b), map_location='cpu')
        return super().find_class(module, name)

# Cargar el modelo de BERTopic en CPU
with open('data/bertopic_model.pkl', 'rb') as f:
    topic_model = CPU_Unpickler(f).load()


# Cargar títulos creados por gemini
with open("data/topic_titles.pkl", 'rb') as f:
    topic_titles = pickle.load(f)


# Cargar embeddings en d2
with open("data/embeddings_2d.pkl", "rb") as f:
    embeddings_2d = pickle.load(f)

print("Terminé de cargar todo")
Terminé de cargar todo

Trabajo previo a la visualización


#  Obtener la información de los documentos
docs_info = topic_model.get_document_info(docs)

# Crear un diccionario con los tópicos a partir de la lista
topic_id_to_title =  {i:topic_titles[i + 1 ]  for i in range(-1, len(topic_titles) - 1)    }

docs_info["Topic_Title"] = docs_info["Topic"].map(topic_id_to_title)

# Obtén los clusters (topics)
topic_labels = np.array(topics)


# Filtra los outliers (topic -1)
mask = topic_labels != -1
embeddings_2d_filtered = embeddings_2d[mask]
topic_labels_filtered = topic_labels[mask]
custom_labels_filtered = docs_info["Topic_Title"][mask]
titles_filtered = np.array(docs)[mask]  # docs debe ser la lista de títulos

Visualización interactiva

# Acomodar los nombres de los clusters para el paquete de visualización
arr_str = custom_labels_filtered.astype(str)

# Armar la leyenda que aparece en el hover
combined_hover_text = [f"NOTICIA: {title}\nCLUSTER {text}" for title, text in zip(titles_filtered, arr_str)]

# Visualización
plot = dmp.create_interactive_plot(
    embeddings_2d_filtered,
    arr_str,
    hover_text=combined_hover_text
)

plot

Más recursos

Libro sobre text mining con R

Encontrando k óptimo

En resumen

Hemos revisado varias estrategias para trabajar con texto

  • Procesamiento básico con stringr
  • Herramientas para POS con udpipe
  • Exploración y procesamiento de textos con quanteda
  • Transformación en vectores (tfidf)
  • Utilización de embeddings con Python (combinación con reticulate)
  • Algunas herramientas no supervisadas

Cuentan con una serie de herramientas para desarrollar su trabajo final

Métodos computacionales para las ciencias sociales